fix: intermittent media 401 errors after token refresh or SW restart#548
Open
Just-Insane wants to merge 18 commits intoSableClient:devfrom
Open
fix: intermittent media 401 errors after token refresh or SW restart#548Just-Insane wants to merge 18 commits intoSableClient:devfrom
Just-Insane wants to merge 18 commits intoSableClient:devfrom
Conversation
useAsync re-threw errors after storing them in Error state. Any call site
that discards the returned Promise (e.g. fire-and-forget useEffect calls
like loadSrc() or loadThumbSrc()) produced an 'Uncaught (in promise)'
console error — notably 'Mismatched SHA-256 digest' from encrypted media
decryption failures.
The error is already fully handled: it is stored in AsyncStatus.Error state
and components surface it via a retry button. The re-throw added no value
and only caused noise / unhandled rejection warnings.
Update the test accordingly — the .catch(() => {}) guard was only needed to
silence the re-throw and is no longer necessary.
Stores refresh_token from the login response, passes it and a tokenRefreshFunction to createClient so the SDK can auto-refresh expired access tokens. The callback propagates new tokens to both the sessionsAtom (localStorage) and the service worker via pushSessionToSW, preventing 401 errors on authenticated media after token expiry.
8ecb7cc to
715e9e4
Compare
There is a timing window between when the SDK refreshes its access token (tokenRefreshFunction resolves and pushSessionToSW is called) and when the resulting setSession postMessage is processed by the SW. Media requests that land in this window carry the stale token and receive 401. The browser then retries those image/video loads, hitting the SW again with the same stale token — producing the repeated 401 bursts visible in the console. fetchMediaWithRetry() resolves this by retrying once on 401: it re-checks the in-memory sessions map (and preloadedSession fallback) for a different access token. By the time the retry runs, setSession will normally have been processed and the map will hold the new token. Applied consistently across all four branches of the fetch handler.
ae8c97d to
c177218
Compare
When preloadedSession holds a stale token and the live setSession from the page hasn't arrived yet, fetchMediaWithRetry had no fresher token to retry with and immediately returned the 401 response. Add a second-chance retry: if in-memory token lookup yields nothing better, call requestSessionWithTimeout(1500ms) to ask the live client tab for its current session, then retry with it if the token differs. This fixes the startup race where thumbnails (message sender avatars, URL preview images, etc.) 401 briefly before the page pushes its fresh session to the service worker.
There was a problem hiding this comment.
Pull request overview
This PR targets media reliability by addressing token refresh propagation and service-worker handling of authenticated media fetches, plus reducing noisy “Uncaught (in promise)” console errors in media-loading UI flows.
Changes:
- Adjust
useAsyncCallbackerror handling behavior and update its tests. - Persist OIDC
refresh_token/expires_in_msinto session state and wire atokenRefreshFunctioninto the Matrix client, propagating refreshed access tokens to the service worker. - Add a SW media fetch wrapper that retries once on HTTP 401 using the latest in-memory/live session token.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/sw.ts |
Adds fetchMediaWithRetry() and routes media fetch paths through it to mitigate 401s during token-refresh propagation races. |
src/client/initMatrix.ts |
Passes refresh-token options into createClient and plumbs an optional onTokenRefresh callback to propagate new tokens outward. |
src/app/pages/client/ClientRoot.tsx |
Supplies the onTokenRefresh callback to update sessions state and push updated tokens to the SW. |
src/app/pages/auth/login/loginUtil.ts |
Stores refresh_token and expires_in_ms into the session record on login. |
src/app/hooks/useAsyncCallback.ts |
Changes error handling in the async wrapper (no longer rejects on error) and documents rationale. |
src/app/hooks/useAsyncCallback.test.tsx |
Updates error test to no longer expect a rejected promise. |
.changeset/fix-media-error-handling.md |
Adds a patch changeset describing the combined fixes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- useAsyncCallback: re-throw in catch to preserve rejection semantics; add no-op .catch wrapper in useAsyncCallback to suppress unhandled rejection warnings for fire-and-forget callers - useAsyncCallback.test: expect rejects.toBe(boom) to assert rejection - loginUtil: use != null guards for refresh_token / expires_in_ms to correctly handle zero/falsy-but-present values - initMatrix: use typeof === 'number' guard for expires_in_ms expiry calc - ClientRoot: pass activeSession.userId to both pushSessionToSW calls so the SW session record keeps a complete userId
Just-Insane
added a commit
to Just-Insane/Sable
that referenced
this pull request
Mar 27, 2026
- useAsyncCallback: re-throw in catch to preserve rejection semantics; add no-op .catch wrapper in useAsyncCallback to suppress unhandled rejection warnings for fire-and-forget callers - useAsyncCallback.test: expect rejects.toBe(boom) to assert rejection - loginUtil: use != null guards for refresh_token / expires_in_ms to correctly handle zero/falsy-but-present values - initMatrix: use typeof === 'number' guard for expires_in_ms expiry calc - ClientRoot: pass activeSession.userId to both pushSessionToSW calls so the SW session record keeps a complete userId
Resolve stash conflict in DevelopTools.tsx — keep the improved Rotate Encryption Sessions description and success message text from the stashed changes. Add an immediate synchronous sendSessionToSW() call in index.tsx before React mounts. When the SW is already active (normal page reload), navigator.serviceWorker.controller is set and the postMessage is sent before createRoot().render() runs and before any <img> elements fire fetch events. This discards the stale preloadedSession in the SW (which could be from a previous token that is no longer current) before the first thumbnail/media fetches arrive, preventing the race condition that produced 401 errors on initial load with Retry buttons.
persistSession was called fire-and-forget inside setSession. If the browser killed the service worker between the setSession message being processed and the caches.put call completing, the persisted session cache would retain the previous (stale) token. On the next SW restart, the activate handler loads this stale session into preloadedSession, causing media requests to 401 until the retry path eventually obtains a fresh token from the live page via requestSessionWithTimeout. Using event.waitUntil on the persist/clear operation ensures the browser keeps the SW alive until the cache write resolves, eliminating the race window that could leave the cache in an inconsistent state.
The reload path that fires when the active session user changes was calling pushSessionToSW without the userId argument, which would clear the stored userId in the service worker. Pass activeSession.userId consistently, matching the initial-load and token-refresh call sites.
For media fetches without an immediately-available client session, we used a by-baseUrl fallback that could pick the wrong account token when multiple sessions share one homeserver, causing intermittent 401 responses. Prefer client-specific resolution and only use by-baseUrl fallback when there is exactly one matching session for the request URL.
Extend media interception coverage to include preview_url plus client v3 media endpoints so these requests also get bearer injection and 401 retry handling.
…fetchMediaWithRetry
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Fixes a chain of service worker session bugs that caused intermittent 401/404 errors on authenticated media:
Fixes #
Type of change
Checklist:
AI disclosure:
sw.ts: ThesetSession/clearSessioncache writes are now wrapped inevent.waitUntilso the browser keeps the SW alive until thecaches.putresolves — preventing stale tokens surviving a mid-write SW kill on iOS. Added arequestSessionWithTimeoutfallback infetchMediaWithRetryfor the window between a token refresh completing and the resultingsetSessionmessage arriving.index.tsx: CallspushSessionToSWsynchronously before React renders so the SW has a valid token before the first fetch event.ClientRoot.tsx: WirespushSessionToSWinto the SDK'sonTokenRefreshcallback so OIDC token refreshes propagate to the SW immediately. Decrypt failure and media error async paths now have proper rejection handling to avoid unhandled promise rejections.